在第 1–14 天,我們把「點餐術」練到能下單、驗證、統計,還會和伺服器對話。
但真正的產品,還需要清晰的入口與可分享的路徑——這就是 Vue Router 的舞台。
今天,我們把系統切分為三個「魔法場景」:
/order
:使用者直達下單與列表。/summary
:收單者快速總覽。/order/:id
:客服/同事一鍵直達「某一筆」資料。把網址變成「可溝通的契約」:可直達(少點一步)、可重現(重整/隔天回來也在同一畫面)、可分享(貼給任何人都看同一筆)。
/order
,不要再從首頁繞路。」/summary
。」/order/:id
是唯一連結,雙方對話不迷路。需求 | 角色 | 目的 | 功能 | 使用時機 |
---|---|---|---|---|
以網址直達點餐畫面 | 一般使用者 | 省去從首頁點擊 | 路由 /order |
想直接開始點餐 |
查看整體統計 | 組長/收單者 | 快速掌握數量 | 路由 /summary |
收單前確認總量 |
分享某筆訂單 | 點餐者/客服 | 讓對方直接看到指定訂單 | 路由 /order/:id |
客服查單、對帳 |
為什麼一定要有 /order/:id
?
因為「唯一連結 = 唯一真相入口」。沒有它,只能叫對方自己在總表慢慢找,非常不友善。
通常我們會需要這功能就是直接把unique link貼給別人~這樣他就可以直接倒向那個頁面了
Ps. 大家應該有發現使用router前,網址都是固定不動
的 原因就是這些頁面就是透過前端框架去render出來的他並非是一頁一頁的html舊的網站結構。
那麼我們為了要有特別的URL就會需要用到router的套件幫我們完成這件事情。
今天我們會講解到這些概念~
<router-link>
:安全又優雅的頁內傳送門(避免整頁重新載入)。<router-view>
:頁面容器,負責顯示對應的頁面元件。:id
:像「卷軸編號」,可依參數載入不同內容(例 /order/123
)。接下來就來一一解釋這些咚咚的作用吧~~
路由表(routes)
是什麼:把「網址路徑 ➜ 頁面元件」配對的清單(導航地圖)。
為什麼:讓 Router 知道不同 URL 該顯示哪個頁面。
在哪寫:src/router/index.js
的 createRouter({ routes })
。
範例:
const routes = [
{ path: '/', redirect: '/order' },
{ path: '/order', component: OrderPage },
{ path: '/summary', component: SummaryPage },
]
<router-link>
是什麼:內建的超連結元件。
為什麼:SPA 內部跳轉不重整頁面(更快、更平滑)。
怎麼用:像 <a>
,但用 to
指向路徑。
範例:
<router-link to="/order">點餐之塔</router-link>
<router-link :to="`/order/${id}`">查看詳情</router-link>
<router-view>
是什麼:頁面插槽,Router 會把匹配到的頁面元件渲染在這裡。
為什麼:你的頁面切換都在這個容器發生。
用法:通常寫在 App.vue
的版型區。
範例:
<template>
<nav>…導航…</nav>
<router-view /> <!-- 這裡會顯示對應的頁面 -->
</template>
動態路由 :id
是什麼:帶參數的路徑(像卷軸編號),可顯示不同內容。
為什麼:讓每筆資料都有專屬 URL(可分享/可重現)。
怎麼取值:在頁面裡用 useRoute().params.id
。
範例:
// 定義
{ path: '/order/:id', component: OrderDetailPage }
// 使用(頁面內)
import { useRoute } from 'vue-router'
const route = useRoute()
const id = route.params.id
路由守衛(進階)
是什麼:進入/切換路由前的攔截器(像結界)。
為什麼:檢查登入、權限、或載入前置資料。
在哪寫:全域守衛 router.beforeEach
,或單頁的 beforeRouteEnter
(在 Vue Router 4 可用 Composition API 替代)。
範例(全域):
router.beforeEach((to, from, next) => {
const isLoggedIn = Boolean(localStorage.getItem('token'))
if (to.meta.requiresAuth && !isLoggedIn) {
next('/login')
} else {
next()
}
})
如果把它們放進一句話:
「routes 決定地圖、router-link 是傳送門、router-view 是登場舞台、:id 是卷軸編號、守衛 是入口結界。」
這是我們今天的時序圖流程畫面~
大家可以參考一下~
今天程式流程其實很簡單主要是做router的切割
還有page的建置~
新增 GET /api/orders/:id
:
// backend/server.js (節錄)
app.get("/api/orders/:id", async (req, res) => {
try {
const orders = await readOrders();
const order = orders.find(o => o.id === req.params.id);
if (!order) return res.status(404).json({ error: "找不到指定的訂單" });
res.json(order);
} catch (error) {
res.status(500).json({ error: "無法取得訂單" });
}
});
package.json
已加入 vue-router
src/main.js
掛載 router
day15/frontend/src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import { router } from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
src/router/index.js
定義 /order
、/summary
、/order/:id
App.vue
放全域導覽與 router-view
day15/frontend/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import OrderPage from '../pages/OrderPage.vue'
import SummaryPage from '../pages/SummaryPage.vue'
import OrderDetailPage from '../pages/OrderDetailPage.vue'
const routes = [
{ path: '/', redirect: '/order' },
{ path: '/order', component: OrderPage },
{ path: '/summary', component: SummaryPage },
{ path: '/order/:id', component: OrderDetailPage },
]
export const router = createRouter({
history: createWebHistory(),
routes,
})
day15/frontend/src/App.vue
<script setup>
</script>
<template>
<main class="page">
<h1>飲料點單系統 (Router 版)</h1>
<nav style="display:flex; gap:8px; margin:12px 0;">
<router-link to="/order" class="btn">點餐之塔</router-link>
<router-link to="/summary" class="btn">結算之室</router-link>
</nav>
<router-view />
</main>
</template>
day15/frontend/src/pages/OrderPage.vue
<script setup>
import { onMounted } from 'vue'
import OrderForm from '../components/OrderForm.vue'
import OrderList from '../components/OrderList.vue'
import { useOrderStore } from '../stores/orderStore'
import { useMenuStore } from '../stores/menuStore'
const orderStore = useOrderStore()
const menuStore = useMenuStore()
onMounted(() => {
orderStore.loadOrders()
menuStore.loadMenu()
})
function handleSubmit(payload) {
orderStore.createOrder(payload)
}
function handleEdit({ index, patch }) {
orderStore.updateOrder(index, patch)
}
function handleRemove(index) {
orderStore.removeOrder(index)
}
</script>
<template>
<section>
<div v-if="orderStore.error" class="error-message">
⚠️ {{ orderStore.error }}
<button @click="orderStore.loadOrders" class="btn btn-sm">重新載入</button>
</div>
<div v-if="orderStore.loading" class="loading-message">
🔄 載入中...
</div>
<OrderForm
:disabled="orderStore.loading"
:drinks="menuStore.drinks"
:sweetnessOptions="menuStore.sweetnessOptions"
:iceOptions="menuStore.iceOptions"
:menuRules="menuStore.rules"
@submit="handleSubmit"
/>
<section class="list">
<h3>目前已送出的訂單 ({{ orderStore.orders.length }} 筆)</h3>
<OrderList :orders="orderStore.orders" @edit="handleEdit" @remove="handleRemove" />
</section>
<section class="block" style="margin-top:16px">
<h3>祕書匯入訂單(貼上 JSON 陣列)</h3>
<textarea
:value="orderStore.ordersJson"
@input="orderStore.setOrdersJson($event.target.value)"
style="width:100%; min-height:160px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;"
placeholder='[ { "name": "王小美", "note": "少冰", "drink": "紅茶", "sweetness": "去糖", "ice": "去冰" } ]'
></textarea>
<div class="actions" style="margin-top:8px">
<button class="btn primary" @click="orderStore.replaceAllOrders(JSON.parse(orderStore.ordersJson))">套用到後端</button>
<button class="btn" @click="orderStore.loadOrders">重新載入(後端)</button>
</div>
</section>
</section>
</template>
day15/frontend/src/pages/SummaryPage.vue
<script setup>
import { onMounted } from 'vue'
import { useOrderStore } from '../stores/orderStore'
import { useMenuStore } from '../stores/menuStore'
import OrderStats from '../components/OrderStats.vue'
const orderStore = useOrderStore()
const menuStore = useMenuStore()
onMounted(() => {
if (!orderStore.orders.length) {
orderStore.loadOrders()
}
if (!menuStore.drinks.length) {
menuStore.loadMenu()
}
})
</script>
<template>
<section class="stats">
<h2>結算之室</h2>
<nav style="margin:8px 0">
<router-link class="btn" to="/order">回到點餐</router-link>
</nav>
<OrderStats :orders="orderStore.orders" :summary="orderStore.summaryRows" />
</section>
</template>
day15/frontend/src/pages/OrderDetailPage.vue
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { OrderService } from '../services/orderService'
const route = useRoute()
const router = useRouter()
const order = ref(null)
const loading = ref(false)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
try {
order.value = await OrderService.getById(route.params.id)
} catch (e) {
error.value = '讀取訂單失敗'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section class="block">
<h2>訂單詳情</h2>
<div v-if="loading" class="loading-message">讀取中...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<template v-else-if="order">
<p><b>姓名:</b>{{ order.name }}</p>
<p><b>飲料:</b>{{ order.drink }}</p>
<p><b>甜度:</b>{{ order.sweetness }}</p>
<p><b>冰量:</b>{{ order.ice }}</p>
<p><b>備註:</b>{{ order.note }}</p>
<p><b>建立時間:</b>{{ order.createdAt }}</p>
<p v-if="order.updatedAt"><b>更新時間:</b>{{ order.updatedAt }}</p>
<div class="actions" style="margin-top:12px">
<button class="btn" @click="router.push('/order')">返回列表</button>
</div>
</template>
<div v-else>無資料</div>
</section>
</template>
day15/frontend/src/components/OrderList.vue
<script setup>
import { reactive, ref } from 'vue'
const props = defineProps({ orders: { type: Array, required: true } })
const emit = defineEmits(['edit', 'remove'])
const editIndex = ref(-1)
const editForm = reactive({ name: '', note: '', drink: '', sweetness: '', ice: '' })
function toggleEdit(i){
if (editIndex.value === i) { editIndex.value = -1; return }
editIndex.value = i
Object.assign(editForm, props.orders[i])
}
function applyEdit(){
if (editIndex.value < 0) return
emit('edit', { index: editIndex.value, patch: { ...editForm } })
editIndex.value = -1
}
function cancelEdit(){ editIndex.value = -1 }
function removeOrder(i){
emit('remove', i)
if (editIndex.value === i) editIndex.value = -1
}
</script>
<template>
<ul>
<li v-for="(o, i) in props.orders" :key="i" class="order">
<div class="row">
<div class="col">
<span class="idx">{{ i + 1 }}.</span>
<span class="name">{{ o.name }}</span>
<span class="pill">{{ o.drink }}</span>
<span class="pill" :class="o.ice === '去冰' ? 'is-noice' : 'is-ice'">{{ o.ice }}</span>
<span class="pill" :class="o.sweetness === '去糖' ? 'is-nosugar' : 'is-sugar'">{{ o.sweetness }}</span>
<span v-if="o.note" class="note">備註:{{ o.note }}</span>
</div>
<div class="actions">
<router-link v-if="o.id" class="btn btn-sm" :to="`/order/${o.id}`">詳情</router-link>
<button class="btn btn-sm" @click="toggleEdit(i)">{{ editIndex === i ? '收合' : '編輯' }}</button>
<button class="btn btn-sm del" @click="removeOrder(i)">刪除</button>
</div>
</div>
<!-- 編輯區塊省略 -->
</li>
</ul>
</template>
import { http } from './http'
export const OrderService = {
async list() {
const { data } = await http.get('/api/orders')
return data
},
async getById(id) {
const { data } = await http.get(`/api/orders/${id}`)
return data
},
async create(payload) {
const { data } = await http.post('/api/orders', payload)
return data
},
async update(id, patch) {
const { data } = await http.put(`/api/orders/${id}`, patch)
return data
},
async remove(id) {
const { data } = await http.delete(`/api/orders/${id}`)
return data
},
async replaceAll(orders) {
const { data } = await http.put('/api/orders/bulk', orders)
return data
},
}
import express from "express";
import { promises as fs } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import cors from "cors";
// 🔎 GET /api/orders/:id - 取得指定訂單
app.get("/api/orders/:id", async (req, res) => {
try {
const orders = await readOrders();
const order = orders.find(o => o.id === req.params.id);
if (!order) {
return res.status(404).json({ error: "找不到指定的訂單" });
}
res.json(order);
} catch (error) {
res.status(500).json({ error: "無法取得訂單" });
}
});
前端 OrderService
新增 getById
:
// src/services/orderService.js (節錄)
async getById(id) {
const { data } = await http.get(`/api/orders/${id}`)
return data
}
id
要顯示提示與返回入口。/order/:id
到新分頁,確保仍能顯示(包含前置載入)。今天,我們讓「網址」成為可靠的傳送門:
/order 直達下單、/summary 快速結算、/order/:id 指向唯一真相。
從此之後,客服、對帳、協作,一條連結就搞定。明天,我們可以在這個基礎上加入守衛、查詢字串、或麵包屑,把傳送門魔法升級成一套完整的導航體系。